agentmux_srv\backend\blockcontroller/
mod.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Block controller: manages lifecycle of each block (terminal, command, web app).
5//! Port of Go's pkg/blockcontroller/blockcontroller.go.
6
7//!
8//! Architecture:
9//! - Global controller registry maps block_id → Controller
10//! - Each controller manages the lifecycle of one block
11//! - ShellController handles "shell" and "cmd" block types
12//! - Controllers dispatch I/O between the user and the process/service
13
14pub mod acp;
15pub mod core;
16pub mod health;
17pub mod persistent;
18pub mod pidregistry;
19pub mod process_tree;
20pub mod session_recovery;
21pub mod session_stats;
22pub mod shell;
23pub mod subprocess;
24pub mod watchdog;
25
26use std::any::Any;
27use std::collections::HashMap;
28use std::sync::{Arc, RwLock};
29
30use serde::{Deserialize, Serialize};
31
32use super::eventbus::EventBus;
33use super::storage::filestore::FileStore;
34use super::storage::store::Store;
35use super::obj::{Block, MetaMapType, TermSize};
36use super::wps::Broker;
37
38// ---- Controller status constants (match Go) ----
39
40pub const STATUS_INIT: &str = "init";
41pub const STATUS_RUNNING: &str = "running";
42pub const STATUS_DONE: &str = "done";
43
44// ---- Controller type constants (match Go) ----
45
46pub const BLOCK_CONTROLLER_SHELL: &str = "shell";
47pub const BLOCK_CONTROLLER_CMD: &str = "cmd";
48pub const BLOCK_CONTROLLER_TSUNAMI: &str = "tsunami";
49pub const BLOCK_CONTROLLER_SUBPROCESS: &str = "subprocess";
50pub const BLOCK_CONTROLLER_PERSISTENT: &str = "persistent";
51pub const BLOCK_CONTROLLER_ACP: &str = "acp";
52
53// ---- Block metadata key constants (match Go) ----
54
55pub const META_KEY_CONTROLLER: &str = "controller";
56pub const META_KEY_CONNECTION: &str = "connection";
57pub const META_KEY_CMD: &str = "cmd";
58pub const META_KEY_CMD_CWD: &str = "cmd:cwd";
59#[allow(dead_code)]
60pub const META_KEY_CMD_SHELL: &str = "cmd:shell";
61pub const META_KEY_CMD_ARGS: &str = "cmd:args";
62pub const META_KEY_CMD_ENV: &str = "cmd:env";
63#[allow(dead_code)]
64pub const META_KEY_CMD_JWT: &str = "cmd:jwt";
65pub const META_KEY_CMD_RUN_ON_START: &str = "cmd:runonstart";
66pub const META_KEY_CMD_RUN_ONCE: &str = "cmd:runonce";
67pub const META_KEY_CMD_CLEAR_ON_START: &str = "cmd:clearonstart";
68pub const META_KEY_CMD_CLOSE_ON_EXIT: &str = "cmd:closeonexit";
69pub const META_KEY_CMD_CLOSE_ON_EXIT_FORCE: &str = "cmd:closeonexitforce";
70pub const META_KEY_CMD_CLOSE_ON_EXIT_DELAY: &str = "cmd:closeonexitdelay";
71#[allow(dead_code)]
72pub const META_KEY_CMD_INIT_SCRIPT: &str = "cmd:initscript";
73#[allow(dead_code)]
74pub const META_KEY_CMD_INIT_SCRIPT_BASH: &str = "cmd:initscript.bash";
75#[allow(dead_code)]
76pub const META_KEY_CMD_INIT_SCRIPT_ZSH: &str = "cmd:initscript.zsh";
77#[allow(dead_code)]
78pub const META_KEY_CMD_INIT_SCRIPT_FISH: &str = "cmd:initscript.fish";
79#[allow(dead_code)]
80pub const META_KEY_CMD_INIT_SCRIPT_PWSH: &str = "cmd:initscript.pwsh";
81#[allow(dead_code)]
82pub const META_KEY_TERM_LOCAL_SHELL_PATH: &str = "term:localshellpath";
83#[allow(dead_code)]
84pub const META_KEY_TERM_LOCAL_SHELL_OPTS: &str = "term:localshellopts";
85
86// ---- Default timeouts ----
87
88/// Default controller operation timeout in milliseconds.
89#[allow(dead_code)]
90pub const DEFAULT_TIMEOUT_MS: u64 = 2000;
91
92/// Grace period before forceful kill in milliseconds.
93#[allow(dead_code)]
94pub const DEFAULT_GRACEFUL_KILL_WAIT_MS: u64 = 400;
95
96// ---- Input union (matches Go's BlockInputUnion) ----
97
98/// Input sent to a block controller.
99/// Can be raw terminal data, a signal, or a resize event.
100#[derive(Debug, Clone)]
101pub struct BlockInputUnion {
102    /// Raw terminal input bytes (base64 decoded from wire format).
103    pub input_data: Option<Vec<u8>>,
104    /// Signal name (e.g., "SIGTERM", "SIGINT").
105    pub sig_name: Option<String>,
106    /// Terminal resize event.
107    pub term_size: Option<TermSize>,
108}
109
110impl BlockInputUnion {
111    pub fn data(data: Vec<u8>) -> Self {
112        Self {
113            input_data: Some(data),
114            sig_name: None,
115            term_size: None,
116        }
117    }
118
119    pub fn signal(name: &str) -> Self {
120        Self {
121            input_data: None,
122            sig_name: Some(name.to_string()),
123            term_size: None,
124        }
125    }
126
127    pub fn resize(size: TermSize) -> Self {
128        Self {
129            input_data: None,
130            sig_name: None,
131            term_size: Some(size),
132        }
133    }
134}
135
136fn is_false(v: &bool) -> bool {
137    !v
138}
139
140// ---- Runtime status (matches Go's BlockControllerRuntimeStatus) ----
141
142/// Runtime status of a block controller, sent to the UI.
143#[derive(Debug, Clone, Serialize, Deserialize, Default)]
144pub struct BlockControllerRuntimeStatus {
145    pub blockid: String,
146    #[serde(default)]
147    pub version: i32,
148    #[serde(default, skip_serializing_if = "String::is_empty")]
149    pub shellprocstatus: String,
150    #[serde(default, skip_serializing_if = "String::is_empty")]
151    pub shellprocconnname: String,
152    #[serde(default)]
153    pub shellprocexitcode: i32,
154    /// Unix timestamp (ms) when the process was spawned; None until first spawn.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub spawn_ts_ms: Option<i64>,
157    /// True if this pane is running an agent CLI (e.g. claude, codex, gemini, kimi, openclaw, pi).
158    #[serde(default, skip_serializing_if = "is_false")]
159    pub is_agent_pane: bool,
160}
161
162// ---- Controller trait ----
163
164/// Trait for block controllers. Each block type has its own implementation.
165/// Port of Go's `blockcontroller.Controller` interface.
166pub trait Controller: Send + Sync {
167    /// Start the controller. May spawn background tasks.
168    /// `force` restarts even if already running.
169    fn start(
170        &self,
171        block_meta: MetaMapType,
172        rt_opts: Option<serde_json::Value>,
173        force: bool,
174    ) -> Result<(), String>;
175
176    /// Stop the controller.
177    /// `graceful` waits for process to exit; `new_status` is the target state.
178    fn stop(&self, graceful: bool, new_status: &str) -> Result<(), String>;
179
180    /// Get the current runtime status.
181    fn get_runtime_status(&self) -> BlockControllerRuntimeStatus;
182
183    /// Send input (terminal data, signal, or resize) to the controller.
184    /// `seq` is the per-TermViewModel monotonic counter; `None` means fire-and-forget (no ordering).
185    fn send_input(&self, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String>;
186
187    /// Get the controller type (e.g., "shell", "cmd").
188    fn controller_type(&self) -> &str;
189
190    /// Get the block ID.
191    #[allow(dead_code)]
192    fn block_id(&self) -> &str;
193
194    /// Downcast support for concrete controller types.
195    fn as_any(&self) -> &dyn Any;
196}
197
198// ---- Global controller registry ----
199
200/// Thread-safe global controller registry.
201/// Maps block_id → Arc<dyn Controller>.
202static CONTROLLER_REGISTRY: std::sync::LazyLock<RwLock<HashMap<String, Arc<dyn Controller>>>> =
203    std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
204
205/// Get a controller by block ID.
206pub fn get_controller(block_id: &str) -> Option<Arc<dyn Controller>> {
207    CONTROLLER_REGISTRY
208        .read()
209        .unwrap()
210        .get(block_id)
211        .cloned()
212}
213
214/// Register a controller, stopping any previous one for the same block.
215pub fn register_controller(block_id: &str, controller: Arc<dyn Controller>) {
216    let mut registry = CONTROLLER_REGISTRY.write().unwrap();
217    if let Some(old) = registry.remove(block_id) {
218        // Stop the old controller before replacing
219        let _ = old.stop(true, STATUS_DONE);
220    }
221    registry.insert(block_id.to_string(), controller);
222}
223
224/// Unregister (delete) a controller by block ID, stopping it first.
225/// Removes from the registry before calling stop() so no new callers can reach it.
226pub fn delete_controller(block_id: &str) {
227    let ctrl = CONTROLLER_REGISTRY.write().unwrap().remove(block_id);
228    if let Some(ctrl) = ctrl {
229        let _ = ctrl.stop(true, STATUS_DONE);
230    }
231    // Drop the process tracker for this block. On Windows the job
232    // object's `KILL_ON_JOB_CLOSE` flag nukes the whole descendant
233    // tree; on Linux/macOS the tracker's `Drop` does the same.
234    // No-op if the tracker global isn't initialized.
235    if let Some(registry) = crate::backend::process_tracker::registry::global() {
236        registry.remove(block_id);
237    }
238}
239
240/// Get all controllers (snapshot).
241pub fn get_all_controllers() -> HashMap<String, Arc<dyn Controller>> {
242    CONTROLLER_REGISTRY.read().unwrap().clone()
243}
244
245/// Stop all running controllers gracefully.
246#[allow(dead_code)]
247pub fn stop_all_controllers() {
248    let controllers = get_all_controllers();
249    for (_, ctrl) in controllers {
250        let _ = ctrl.stop(true, STATUS_DONE);
251    }
252}
253
254// ---- Public API functions ----
255
256/// Get the runtime status for a block's controller.
257/// Returns None if no controller is registered.
258pub fn get_block_controller_status(block_id: &str) -> Option<BlockControllerRuntimeStatus> {
259    get_controller(block_id).map(|c| c.get_runtime_status())
260}
261
262/// Stop a block's controller gracefully.
263#[allow(dead_code)]
264pub fn stop_block_controller(block_id: &str) -> Result<(), String> {
265    match get_controller(block_id) {
266        Some(ctrl) => ctrl.stop(true, STATUS_DONE),
267        None => Ok(()), // No controller = already stopped
268    }
269}
270
271/// Send input to a block's controller.
272pub fn send_input(block_id: &str, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String> {
273    match get_controller(block_id) {
274        Some(ctrl) => ctrl.send_input(input, seq),
275        None => Err(format!("no controller for block {block_id}")),
276    }
277}
278
279/// How a controller-aware agent message was delivered.
280pub enum AgentDelivery {
281    /// Delivered on the controller's structured input channel — a persistent
282    /// stream-json stdin line or an ACP `session/prompt`. No PTY keystrokes are
283    /// needed, and the message lands on the live channel so the agent picks it up
284    /// mid-turn (steering) instead of only when idle.
285    Structured,
286    /// The controller is PTY/terminal-based (shell/term) or otherwise has no
287    /// structured input channel. The caller should fall back to keystroke
288    /// injection.
289    Pty,
290}
291
292/// Deliver an inter-agent / muxbus message to a running agent the way its controller
293/// expects.
294///
295/// - **Persistent** (stream-json) agents have no PTY: the message is written as a
296///   `{type:"user",…}` line on the live stdin, which steers the agent mid-turn.
297/// - **ACP** agents receive the message as a `session/prompt` (the ACP controller's
298///   `send_input` already wraps raw input that way).
299/// - Everything else (shell/term PTY agents, one-shot subprocess agents) is reported
300///   as [`AgentDelivery::Pty`] so the caller uses keystroke injection — preserving
301///   today's behavior.
302///
303/// This is the controller-aware delivery primitive muxbus Tier-1 needs: PTY
304/// keystrokes silently fail to reach a persistent stream-json agent (it rejects raw
305/// input). Spec: docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md §6 (Phase 3).
306pub fn deliver_agent_message(block_id: &str, message: &str) -> Result<AgentDelivery, String> {
307    let ctrl = get_controller(block_id)
308        .ok_or_else(|| format!("no controller for block {block_id}"))?;
309
310    if let Some(persistent_ctrl) = ctrl
311        .as_any()
312        .downcast_ref::<persistent::PersistentSubprocessController>()
313    {
314        persistent_ctrl.send_user_message(message.to_string())?;
315        return Ok(AgentDelivery::Structured);
316    }
317
318    if ctrl.controller_type() == BLOCK_CONTROLLER_ACP {
319        ctrl.send_input(BlockInputUnion::data(message.as_bytes().to_vec()), None)?;
320        return Ok(AgentDelivery::Structured);
321    }
322
323    Ok(AgentDelivery::Pty)
324}
325
326/// Resync a block's controller — the main entry point for starting/restarting blocks.
327/// Port of Go's `ResyncController`.
328///
329/// Logic:
330/// 1. Load block from database
331/// 2. Determine controller type from meta["controller"]
332/// 3. If existing controller needs replacing (type changed, conn changed, force), stop it
333/// 4. Create new controller if needed
334/// 5. Start if status is init or done
335pub fn resync_controller(
336    block: &Block,
337    tab_id: &str,
338    rt_opts: Option<serde_json::Value>,
339    force: bool,
340    broker: Option<Arc<Broker>>,
341    event_bus: Option<Arc<EventBus>>,
342    wstore: Option<Arc<Store>>,
343    filestore: Option<Arc<FileStore>>,
344) -> Result<(), String> {
345    let block_id = &block.oid;
346    let block_meta = &block.meta;
347
348    // Get controller type from block meta
349    let controller_type = super::obj::meta_get_string(block_meta, META_KEY_CONTROLLER, "");
350
351    if controller_type.is_empty() {
352        // No controller type = web/static block, nothing to manage
353        return Ok(());
354    }
355
356    tracing::info!(
357        block_id = %block_id,
358        controller_type = %controller_type,
359        wstore_present = wstore.is_some(),
360        event_bus_present = event_bus.is_some(),
361        force,
362        "[dnd-debug] resync_controller entry"
363    );
364
365    // Check if existing controller needs to be replaced
366    let existing = get_controller(block_id);
367    if let Some(ref ctrl) = existing {
368        let needs_replace = if ctrl.controller_type() != controller_type || force {
369            true // Type changed or forced restart
370        } else {
371            let status = ctrl.get_runtime_status();
372            // Check if connection changed
373            let new_conn =
374                super::obj::meta_get_string(block_meta, META_KEY_CONNECTION, "local");
375            status.shellprocconnname != new_conn
376        };
377
378        if needs_replace {
379            let _ = ctrl.stop(true, STATUS_DONE);
380            delete_controller(block_id);
381        } else {
382            // Existing controller is fine, just check if it needs starting
383            let status = ctrl.get_runtime_status();
384            tracing::info!(
385                block_id = %block_id,
386                status = %status.shellprocstatus,
387                "[dnd-debug] existing controller — skipping spawn (no cmd:cwd seed)"
388            );
389            if status.shellprocstatus == STATUS_INIT || status.shellprocstatus == STATUS_DONE {
390                return ctrl.start(block_meta.clone(), rt_opts, force);
391            }
392            return Ok(());
393        }
394    }
395
396    // Create new controller
397    match controller_type.as_str() {
398        BLOCK_CONTROLLER_SHELL | BLOCK_CONTROLLER_CMD => {
399            let ctrl = shell::ShellController::new(
400                controller_type.clone(),
401                tab_id.to_string(),
402                block_id.to_string(),
403                broker,
404                event_bus,
405                wstore,
406            );
407            let ctrl = Arc::new(ctrl);
408            register_controller(block_id, ctrl.clone());
409            ctrl.start(block_meta.clone(), rt_opts, force)
410        }
411        BLOCK_CONTROLLER_SUBPROCESS => {
412            let ctrl = subprocess::SubprocessController::new(
413                tab_id.to_string(),
414                block_id.to_string(),
415                broker,
416                event_bus,
417                wstore,
418                filestore,
419            );
420            let ctrl = Arc::new(ctrl);
421            ctrl.set_self_ref();
422            register_controller(block_id, ctrl.clone());
423            ctrl.start(block_meta.clone(), rt_opts, force)
424        }
425        BLOCK_CONTROLLER_PERSISTENT => {
426            let ctrl = persistent::PersistentSubprocessController::new(
427                tab_id.to_string(),
428                block_id.to_string(),
429                broker,
430                event_bus,
431                wstore,
432                filestore,
433            );
434            let ctrl = Arc::new(ctrl);
435            register_controller(block_id, ctrl.clone());
436            ctrl.start(block_meta.clone(), rt_opts, force)
437        }
438        BLOCK_CONTROLLER_ACP => {
439            let ctrl = acp::AcpController::new(
440                tab_id.to_string(),
441                block_id.to_string(),
442                broker,
443                event_bus,
444                wstore,
445                filestore,
446            );
447            let ctrl = Arc::new(ctrl);
448            register_controller(block_id, ctrl.clone());
449            ctrl.start(block_meta.clone(), rt_opts, force)
450        }
451        BLOCK_CONTROLLER_TSUNAMI => {
452            // Tsunami controller deferred to later phase
453            Err("tsunami controller not yet implemented".to_string())
454        }
455        _ => Err(format!("unknown controller type: {controller_type}")),
456    }
457}
458
459/// Publish a controller status event via WPS broker.
460pub fn publish_controller_status(
461    broker: &super::wps::Broker,
462    status: &BlockControllerRuntimeStatus,
463) {
464    use super::wps::{WaveEvent, EVENT_CONTROLLER_STATUS};
465
466    let event = WaveEvent {
467        event: EVENT_CONTROLLER_STATUS.to_string(),
468        scopes: vec![format!("block:{}", status.blockid)],
469        sender: String::new(),
470        persist: 0,
471        data: serde_json::to_value(status).ok(),
472    };
473    broker.publish(event);
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_status_constants() {
482        assert_eq!(STATUS_INIT, "init");
483        assert_eq!(STATUS_RUNNING, "running");
484        assert_eq!(STATUS_DONE, "done");
485    }
486
487    #[test]
488    fn test_controller_type_constants() {
489        assert_eq!(BLOCK_CONTROLLER_SHELL, "shell");
490        assert_eq!(BLOCK_CONTROLLER_CMD, "cmd");
491        assert_eq!(BLOCK_CONTROLLER_TSUNAMI, "tsunami");
492    }
493
494    #[test]
495    fn test_meta_key_constants() {
496        assert_eq!(META_KEY_CONTROLLER, "controller");
497        assert_eq!(META_KEY_CONNECTION, "connection");
498        assert_eq!(META_KEY_CMD, "cmd");
499        assert_eq!(META_KEY_CMD_RUN_ON_START, "cmd:runonstart");
500    }
501
502    #[test]
503    fn test_block_input_union_data() {
504        let input = BlockInputUnion::data(b"hello".to_vec());
505        assert_eq!(input.input_data.as_ref().unwrap(), b"hello");
506        assert!(input.sig_name.is_none());
507        assert!(input.term_size.is_none());
508    }
509
510    #[test]
511    fn test_block_input_union_signal() {
512        let input = BlockInputUnion::signal("SIGTERM");
513        assert!(input.input_data.is_none());
514        assert_eq!(input.sig_name.as_ref().unwrap(), "SIGTERM");
515        assert!(input.term_size.is_none());
516    }
517
518    #[test]
519    fn test_block_input_union_resize() {
520        let size = TermSize { rows: 40, cols: 120 };
521        let input = BlockInputUnion::resize(size.clone());
522        assert!(input.input_data.is_none());
523        assert!(input.sig_name.is_none());
524        let ts = input.term_size.unwrap();
525        assert_eq!(ts.rows, 40);
526        assert_eq!(ts.cols, 120);
527    }
528
529    #[test]
530    fn test_runtime_status_default() {
531        let status = BlockControllerRuntimeStatus::default();
532        assert!(status.blockid.is_empty());
533        assert_eq!(status.version, 0);
534        assert!(status.shellprocstatus.is_empty());
535        assert_eq!(status.shellprocexitcode, 0);
536    }
537
538    #[test]
539    fn test_runtime_status_serde() {
540        let status = BlockControllerRuntimeStatus {
541            blockid: "block-123".to_string(),
542            version: 3,
543            shellprocstatus: STATUS_RUNNING.to_string(),
544            shellprocconnname: "local".to_string(),
545            shellprocexitcode: 0,
546            ..Default::default()
547        };
548        let json = serde_json::to_string(&status).unwrap();
549        assert!(json.contains("\"blockid\":\"block-123\""));
550        assert!(json.contains("\"shellprocstatus\":\"running\""));
551
552        let parsed: BlockControllerRuntimeStatus = serde_json::from_str(&json).unwrap();
553        assert_eq!(parsed.blockid, "block-123");
554        assert_eq!(parsed.version, 3);
555    }
556
557    #[test]
558    fn test_get_nonexistent_controller() {
559        assert!(get_controller("nonexistent-block").is_none());
560    }
561
562    #[test]
563    fn test_get_block_controller_status_none() {
564        assert!(get_block_controller_status("nonexistent").is_none());
565    }
566
567    #[test]
568    fn test_stop_nonexistent_controller() {
569        // Should be ok (no-op)
570        assert!(stop_block_controller("nonexistent").is_ok());
571    }
572
573    #[test]
574    fn test_send_input_no_controller() {
575        let result = send_input("nonexistent", BlockInputUnion::data(b"test".to_vec()), None);
576        assert!(result.is_err());
577        assert!(result.unwrap_err().contains("no controller"));
578    }
579
580    #[test]
581    fn test_resync_no_controller_type() {
582        let block = Block {
583            oid: "test-block".to_string(),
584            version: 1,
585            meta: HashMap::new(),
586            ..Default::default()
587        };
588        // No "controller" key in meta = no-op
589        let result = resync_controller(&block, "tab-1", None, false, None, None, None, None);
590        assert!(result.is_ok());
591    }
592
593    #[test]
594    fn test_resync_unknown_controller_type() {
595        let mut meta = MetaMapType::new();
596        meta.insert(
597            "controller".to_string(),
598            serde_json::Value::String("unknown_type".to_string()),
599        );
600        let block = Block {
601            oid: "test-block".to_string(),
602            version: 1,
603            meta,
604            ..Default::default()
605        };
606        let result = resync_controller(&block, "tab-1", None, false, None, None, None, None);
607        assert!(result.is_err());
608        assert!(result.unwrap_err().contains("unknown controller type"));
609    }
610}